API Design Patterns — 13장 교차 참조 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
13장에서 다루는 내용
- 다른 리소스를 참조하기 위해 사용되는 이름 필드 — authorId, targetId 등
- 참조 필드가 참조 무결성 문제를 다루는 방법 — 허상 포인터, 삭제 대응
- 필드가 리소스 데이터 복사본을 저장하는 시점과 그 이유 — 값 대 참조
- 동적 리소스 타입 참조 — 여러 타입을 가리킬 수 있는 참조
전체 흐름도
[리소스 간 관계] [참조 방식] [무결성 처리]
| | |
v v v
+------------------+ +---------------------+ +---------------------------+
| Book 리소스가 | | 참조 필드 | | 13.3.2 데이터 무결성 |
| Author를 가리켜야 |---->| authorId: string |---->| 허상 포인터 대응 3가지: |
| 한다 | | (고유 식별자 저장) | | 1. 삭제 금지 |
+------------------+ +---------------------+ | 2. 참조 필드 0으로 설정 |
| | | 3. 유효하지 않은 참조 허용|
v v +---------------------------+
+----------+ +----------+ |
| 정적 참조| | 동적 참조| +---------------------------+
| Book → | | targetId | | 13.3.3 값 대 참조 |
| Author | | + | | 참조(포인터): 항상 최신 |
| (고정) | | targetType| | 값(복사본): 스냅샷 |
+----------+ +----------+ +---------------------------+
선수 지식 체크리스트
- [ ] REST API 기본 개념 (리소스, 엔드포인트)
- [ ] 표준 메서드 (Get, Create, Delete)
- [ ] 고유 식별자 개념 (6장)
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 교차 참조 | 한 리소스가 다른 리소스를 가리키는 것 |
| 참조 필드 | 다른 리소스의 고유 식별자를 저장하는 문자열 필드 (예: authorId) |
| 허상 포인터 | 삭제된 리소스를 가리키는 유효하지 않은 참조 |
| 동적 리소스 타입 | 참조 대상의 타입이 상황에 따라 달라질 수 있는 참조 |
| 값 대 참조 | 데이터의 복사본을 저장할지(값), 포인터만 저장할지(참조) 선택 |
| 순환 참조 | 두 리소스가 서로를 참조하는 상태 |
13.1 서론 — 왜 리소스가 다른 리소스를 참조해야 하는가?
한 줄 요약
리소스가 홀로 존재하는 경우는 거의 없다. 다른 리소스를 가리키는 방법이 필요하다!
쉬운 설명 (도서관 비유)
도서관에서 책(Book)과 저자(Author)를 관리한다고 하자.
"이 책은 누가 썼는지 어떻게 기록할까?"
- 방법 1: 책 카드에 저자 이름을 직접 적는다 → 저자가 이름을 바꾸면? 모든 책 카드를 수정해야!
- 방법 2: 책 카드에 "저자 번호: A-123"만 적고, 저자 정보는 저자 카드에서 찾는다 → 이게 교차 참조!
[Book 카드] [Author 카드]
┌──────────────────┐ ┌──────────────────┐
│ id: "books/1234" │ │ id: "authors/1" │
│ title: "해리포터"│ │ name: "J.K.롤링" │
│ authorId: ───────┼─────────>│ │
│ "authors/1" │ └──────────────────┘
└──────────────────┘
실무 예제 — 참조가 필요한 다양한 상황
온라인 쇼핑몰:
Order (주문) → Customer (고객) : customerId
Order (주문) → Product (상품) : productId
Review (리뷰) → Product (상품) : productId
Review (리뷰) → Customer (고객) : authorId
블로그 플랫폼:
Post (게시글) → Author (작성자) : authorId
Comment (댓글) → Post (게시글) : postId
Comment (댓글) → Author (작성자) : authorId
CI/CD 시스템:
Build (빌드) → Repository (저장소) : repositoryId
Build (빌드) → Commit (커밋) : commitId
Deployment (배포) → Build (빌드) : buildId
참조의 범위 3가지
┌─────────────────────────────────────────────────────────────────┐
│ 범위 1: 로컬 참조 (같은 API 내) │
│ Book → Author (books API 안에서 다른 리소스 참조) │
│ 가장 일반적, 관리가 쉬움 │
├─────────────────────────────────────────────────────────────────┤
│ 범위 2: 같은 서비스 제공자 내 (다른 API) │
│ Order → PaymentTransaction (주문 API → 결제 API) │
│ 같은 회사라 신뢰 가능, 하지만 API 변경 시 영향 있음 │
├─────────────────────────────────────────────────────────────────┤
│ 범위 3: 전역 참조 (외부 서비스) │
│ Book → ISBN (도서 API → 국제 ISBN 데이터베이스) │
│ 표준 URI 사용, 외부 서비스 장애 시 참조 확인 불가 │
└─────────────────────────────────────────────────────────────────┘
핵심 체크포인트
- ✅ 리소스는 거의 항상 다른 리소스와 관계를 가진다
- ✅ 참조는 고유 식별자(문자열)를 사용한다
- ✅ 참조와 가리키는 리소스는 완전히 분리되어 있다
- ✅ 범위가 넓어질수록(로컬→전역) 관리가 복잡해진다
13.2 개괄 — 교차 참조 패턴의 기본 원리
한 줄 요약
참조 속성에 다른 리소스의 고유 식별자(문자열)를 저장하고, 필요하면 그 식별자로 해당 리소스를 조회한다.
Java/DB 비유
// Java에서의 참조 — 객체 포인터
class Book {
String id;
String title;
Author author; // ← 객체 참조 (메모리 주소)
}
// API에서의 참조 — 문자열 식별자
interface Book {
id: string;
title: string;
authorId: string; // ← 문자열 참조 ("authors/1")
}
// 실제 Author 데이터가 필요하면 별도 API 호출: GET /authors/1
장점과 단점 상세
| 장점 | 상세 설명 |
|---|---|
| 유연성 | 어떤 리소스든 식별자만 있으면 참조 가능 |
| 순환 참조 안전 | 포인터만 저장하므로 객체가 잠기지 않음 |
| 확장성 | 하나의 리소스가 수천 개의 다른 리소스를 참조 가능 |
| 분리 | 참조하는 리소스와 참조되는 리소스가 완전히 독립적 |
| 단점 | 상세 설명 |
|---|---|
| 추가 API 호출 | 참조 데이터를 보려면 별도 GET 요청 필요 |
| 허상 포인터 | 참조 대상이 삭제되면 유효하지 않은 참조가 남음 |
| 타입 안전성 없음 | 문자열이므로 잘못된 ID를 저장해도 컴파일 에러 없음 |
핵심 체크포인트
- ✅ 참조는 문자열 식별자로 저장 (타입 안전성 없음)
- ✅ 참조는 가리키는 리소스와 독립적
- ✅ 참조 대상이 이동/삭제될 수 있음을 항상 고려
13.3 구현
13.3.1 참조 필드 이름
한 줄 요약
참조 필드 이름으로 타입과 목적을 전달한다. 정적 참조(authorId)와 동적 참조(targetId + targetType)가 있다.
정적 리소스 타입 참조
// Book이 항상 Author를 참조 (정적)
interface Book {
id: string;
authorId: string; // ← "authors/1" 형태의 Author 고유 식별자
title: string;
publisherId: string; // ← 출판사 참조도 동일한 패턴
}
interface Author {
id: string; // ← authorId에 들어가는 값
name: string;
}
네이밍 규칙: 참조대상타입 + Id → authorId, publisherId, categoryId
실무 예제 — 다양한 정적 참조
// 온라인 쇼핑몰의 주문
interface Order {
id: string; // "orders/ord-123"
customerId: string; // "customers/cust-456" ← 누가 주문했는가
shippingAddressId: string;// "addresses/addr-789" ← 어디로 배송하는가
paymentMethodId: string; // "payments/pm-012" ← 어떻게 결제하는가
items: OrderItem[]; // 주문 항목 (인라인)
}
interface OrderItem {
productId: string; // "products/prod-345" ← 어떤 상품인가
quantity: number;
unitPrice: number;
}
// 참조 체인: Order → Customer, Address, PaymentMethod, Product
// 주문 하나가 4종류의 다른 리소스를 참조!
동적 리소스 타입 참조
// 변경 이력 — 어떤 타입의 리소스든 참조 가능 (동적)
interface ChangeLogEntry {
id: string;
targetId: string; // ← "authors/1" 또는 "books/234" 또는 "publishers/5"
targetType: string; // ← "Author" 또는 "Book" 또는 "Publisher"
action: string; // "created" | "updated" | "deleted"
timestamp: DateTime;
description: string;
}
// 사용 예시
{
id: "logs/1",
targetId: "authors/1",
targetType: "Author",
action: "updated",
timestamp: "2024-03-15T09:00:00Z",
description: "이름이 'J.K. Rowling'에서 'Joanne Rowling'으로 변경됨"
}
{
id: "logs/2",
targetId: "books/234",
targetType: "Book",
action: "created",
timestamp: "2024-03-15T10:30:00Z",
description: "새 책 '해리포터와 마법사의 돌' 생성됨"
}
왜 동적 참조? 감사 로그, 변경 이력, 알림 시스템 등에서 모든 타입의 리소스를 기록해야 할 때.
정적 참조 vs 동적 참조 비교
| 항목 | 정적 참조 | 동적 참조 |
|---|---|---|
| 필드 수 | 1개 (authorId) | 2개 (targetId + targetType) |
| 대상 타입 | 항상 고정 | 상황에 따라 변경 |
| 사용 시점 | 대부분의 경우 | 감사 로그, 알림, 변경 이력 |
| 타입 안전성 | 약간 있음 (필드명으로 추측) | 없음 (targetType 확인 필요) |
| 복잡도 | 낮음 | 중간 |
13.3.2 데이터 무결성
한 줄 요약
참조 대상이 삭제되면 허상 포인터가 생긴다. 3가지 대응 방식이 있으며, 허용이 권장된다.
허상 포인터 문제 — 실무 시나리오
// === 시나리오: 저자 삭제 후 책의 참조가 깨지는 과정 ===
// 1단계: 저자 생성
POST /authors { name: "Michelle Obama" }
→ { id: "authors/1", name: "Michelle Obama" }
// 2단계: 그 저자를 참조하는 책 생성
POST /books { title: "Becoming", authorId: "authors/1" }
→ { id: "books/234", title: "Becoming", authorId: "authors/1" }
// 3단계: 저자 삭제!
DELETE /authors/1
→ 204 No Content
// 4단계: 책 조회 — authorId가 여전히 "authors/1"을 가리킴
GET /books/234
→ { id: "books/234", title: "Becoming", authorId: "authors/1" }
// 5단계: 해당 저자를 조회하면?
GET /authors/1
→ 404 Not Found ← 허상 포인터!
// 클라이언트가 이 상황을 처리해야 함
3가지 대응 방식 상세
┌──────────────────────────────────────────────────────────────┐
│ 방식 1: 삭제 금지 (Restrict) │
│ │
│ DELETE /authors/1 │
│ → 409 Conflict "이 저자를 참조하는 책이 있습니다" │
│ │
│ 장점: 허상 포인터 완전 방지 │
│ 단점: │
│ - 삭제 전 모든 참조를 먼저 제거해야 함 │
│ - 순환 참조 시 아무것도 삭제 불가! (교착 상태) │
│ 예: Book의 authorId → Author │
│ Author의 favoriteBookId → Book │
│ → Author를 삭제하려면 Book의 참조를 먼저 제거 │
│ → Book을 삭제하려면 Author의 참조를 먼저 제거 │
│ → 둘 다 삭제 불가! │
│ │
│ 적합한 경우: 순환 참조가 없고, 참조 수가 적은 시스템 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 방식 2: 삭제 허용 + 참조 필드 재설정 (Set Null) │
│ │
│ DELETE /authors/1 │
│ → 저자 삭제됨 │
│ → books/234의 authorId가 자동으로 null로 변경됨 │
│ → books/567의 authorId도 자동으로 null로 변경됨 │
│ → ... (이 저자의 모든 책이 업데이트됨) │
│ │
│ 장점: 허상 포인터 방지 │
│ 단점: │
│ - 수천 권의 책을 업데이트해야 할 수 있음 (느림) │
│ - 원자적 처리 불가능 (중간에 일부만 업데이트된 상태) │
│ - delete가 "부수 효과 없음" 원칙에 위배 │
│ │
│ 적합한 경우: 참조 수가 적고, 일관성이 중요한 소규모 시스템 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 방식 3: 삭제 허용 + 허상 포인터 허용 (권장!) ✅ │
│ │
│ DELETE /authors/1 │
│ → 저자 삭제됨 │
│ → books/234의 authorId는 여전히 "authors/1" (그대로 유지) │
│ │
│ 장점: │
│ - 가장 단순하고 일관적 │
│ - delete 메서드의 원자성과 부수효과 없음 원칙 준수 │
│ - 어떤 규모에서든 동일하게 동작 │
│ │
│ 단점: │
│ - 클라이언트가 참조를 항상 확인해야 함 │
│ - 허상 포인터가 시간이 지나면 쌓일 수 있음 │
│ │
│ 클라이언트 처리 패턴: │
│ let book = GetBook({ id: "books/234" }); │
│ try { │
│ let author = GetAuthor({ id: book.authorId }); │
│ showBookWithAuthor(book, author); │
│ } catch (NotFoundError) { │
│ showBookWithoutAuthor(book); // 저자 없이 표시 │
│ } │
│ │
│ 적합한 경우: 대부분의 시스템 (기본 선택) │
└──────────────────────────────────────────────────────────────┘
순환 참조 시나리오
Book {
id: "books/1",
authorId: "authors/1" // → Author를 참조
}
Author {
id: "authors/1",
favoriteBookId: "books/1" // → Book을 참조
}
# 방식 1(삭제 금지)을 사용하면:
# Author를 삭제하려면 → Book의 authorId를 먼저 제거
# Book을 삭제하려면 → Author의 favoriteBookId를 먼저 제거
# 둘 다 삭제 불가! → 교착 상태(deadlock)!
#
# 이것이 방식 3(허상 허용)이 권장되는 핵심 이유
13.3.3 값 대 참조
한 줄 요약
참조(포인터)는 항상 최신 데이터를 보장하고, 값(복사본)은 추가 API 호출 없이 바로 사용 가능하다.
참조 방식 (pass by reference) — 기본
// 책의 저자 이름을 알려면 2번의 API 호출 필요
let book = GetBook({ id: "books/1234" });
// book = { id: "books/1234", title: "Becoming", authorId: "authors/1" }
let author = GetAuthor({ id: book.authorId });
// author = { id: "authors/1", name: "Michelle Obama" }
console.log(`${book.title} by ${author.name}`);
// "Becoming by Michelle Obama"
값 방식 (pass by value) — 인라인 복사본
// 책 리소스에 저자 정보를 복사본으로 포함
interface BookWithInlineAuthor {
id: string;
title: string;
authorId: string; // ← 참조 (포인터)
author: { // ← 복사본 (스냅샷, id 없음!)
name: string;
// id를 포함하지 않음! (원본과 혼동 방지)
};
}
// 1번의 API 호출로 충분
let book = GetBook({ id: "books/1234" });
// book = {
// id: "books/1234",
// title: "Becoming",
// authorId: "authors/1",
// author: { name: "Michelle Obama" } ← 복사본!
// }
console.log(`${book.title} by ${book.author.name}`);
// "Becoming by Michelle Obama" — 추가 API 호출 없음!
인라인 복사본에 id를 생략해야 하는 이유
// ❌ 잘못된 설계: 복사본에 id 포함
interface Book {
id: string;
author: {
id: string; // ← id가 있으면 클라이언트가 혼동!
name: string;
};
}
// 클라이언트가 이렇게 잘못 사용할 수 있음:
UpdateAuthor({ id: book.author.id, name: "New Name" });
// 이게 되는 건가? 복사본을 수정하면 원본도 바뀌는 건가? → 혼란!
// ✅ 올바른 설계: 복사본에 id 생략
interface Book {
id: string;
authorId: string; // ← 원본 참조 (수정하려면 이걸로 접근)
author: {
name: string; // ← 복사본 (읽기 전용 스냅샷)
};
}
서버 측 구현 전략
전략 1: 매 요청마다 DB JOIN으로 최신 데이터 취득
┌────────────────────────────────────────────┐
│ GET /books/1234 │
│ → SELECT b.*, a.name as author_name │
│ FROM books b │
│ JOIN authors a ON b.author_id = a.id │
│ WHERE b.id = 'books/1234' │
│ │
│ 장점: 항상 최신 저자 이름 │
│ 단점: 매 요청마다 JOIN → DB 부하 증가 │
└────────────────────────────────────────────┘
전략 2: 리소스 내에 캐시 복사본 저장
┌────────────────────────────────────────────┐
│ books 테이블: │
│ { id, title, author_id, author_name_cache }│
│ │
│ GET /books/1234 │
│ → author_name_cache에서 바로 반환 │
│ │
│ Author 업데이트 시: │
│ → 해당 저자의 모든 책의 캐시도 업데이트 │
│ │
│ 장점: 빠름 (JOIN 불필요) │
│ 단점: 캐시 불일치 위험 │
└────────────────────────────────────────────┘
비교 정리
| 항목 | 참조 (포인터) | 값 (복사본) |
|---|---|---|
| API 호출 수 | 2번 (Book + Author) | 1번 (Book에 포함) |
| 데이터 최신성 | 항상 최신 | 취득 시점 스냅샷 |
| 응답 크기 | 작음 | 큼 (포함된 데이터만큼) |
| 서버 복잡도 | 낮음 | 높음 (캐시/JOIN 관리) |
| 업데이트 혼란 | 없음 | Book Update 시 author도 수정 가능한지 혼란 |
권장
참조(포인터) 방식이 기본. 복사본은 응답 크기 증가와 데이터 일관성 문제를 야기하므로, 꼭 필요한 경우에만 사용. 대안으로 GraphQL을 활용하면 한 번의 쿼리로 참조 데이터까지 취득 가능.
13.3.4 최종 API 정의
abstract class BookApi {
static version = "v1";
// === Author 표준 메서드 ===
@post("/authors")
CreateAuthor(req: CreateAuthorRequest): Author;
@get("/{id=authors/*}")
GetAuthor(req: GetAuthorRequest): Author;
@get("/authors")
ListAuthors(req: ListAuthorsRequest): ListAuthorsResponse;
@patch("/{resource.id=authors/*}")
UpdateAuthor(req: UpdateAuthorRequest): Author;
@delete("/{id=authors/*}")
DeleteAuthor(req: DeleteAuthorRequest): void;
// === Book 표준 메서드 ===
@post("/books")
CreateBook(req: CreateBookRequest): Book;
@get("/{id=books/*}")
GetBook(req: GetBookRequest): Book;
@get("/books")
ListBooks(req: ListBooksRequest): ListBooksResponse;
@patch("/{resource.id=books/*}")
UpdateBook(req: UpdateBookRequest): Book;
@delete("/{id=books/*}")
DeleteBook(req: DeleteBookRequest): void;
}
// 정적 참조 — 항상 Author를 가리킴
interface Book {
id: string; // "books/1234"
authorId: string; // ← 정적 참조 필드 (Author의 id)
title: string;
}
// 참조 대상
interface Author {
id: string; // "authors/1"
name: string;
}
// 동적 참조 — 어떤 타입이든 가리킬 수 있음
interface ChangeLogEntry {
id: string; // "logs/1"
targetId: string; // ← 동적 참조 (대상 식별자)
targetType: string; // ← 대상 타입 ("Author", "Book" 등)
action: string; // "created" | "updated" | "deleted"
description: string;
}
핵심 체크포인트
- ✅ 참조 필드 이름:
대상타입 + Id(authorId, targetId) - ✅ 동적 참조: targetId + targetType 조합
- ✅ 허상 포인터: 삭제 허용 + 클라이언트가 확인 (방식 3 권장)
- ✅ 기본은 참조(포인터), 복사본은 신중히
- ✅ 인라인 복사본에 id 필드를 포함하지 않음
13.4 트레이드오프
한 줄 요약
참조 방식의 주된 트레이드오프는 연관 정보를 얻기 위해 복수의 API 호출이 필요하다는 것.
# 책과 저자 이름을 함께 보여주려면?
# REST 방식: 2번 호출
GET /books/1234 → { authorId: "authors/1", title: "..." }
GET /authors/1 → { name: "Michelle Obama" }
# GraphQL 방식: 1번 호출
query {
book(id: "books/1234") {
title
author { ← 참조를 자동으로 풀어줌!
name
}
}
}
→ { title: "Becoming", author: { name: "Michelle Obama" } }
핵심: REST에서 참조의 단점(추가 호출)은 GraphQL이나 BFF 패턴으로 보완 가능.
13.5 연습문제 풀이
Q1: 외래 리소스 데이터의 복사본을 저장하는 것이 참조보다 더 유리한 경우는?
A: 참조 대상이 거의 변경되지 않고, 매번 추가 API 호출의 비용이 큰 경우. 예: 국가 코드, 통화 정보, 시간대 등 거의 불변인 참조 데이터. 또는 모바일 앱에서 오프라인 지원이 필요한 경우.
Q2: API 시스템에서 참조 무결성을 유지하는 것이 가능하지 않은 이유는?
A: 분산 시스템에서 원자적 트랜잭션을 보장하기 어렵기 때문. 참조 대상 삭제 시 모든 참조를 동시에 업데이트하는 것은 대규모 시스템에서 사실상 불가능하다. 또한 delete 메서드의 "부수 효과 없음" 원칙에 위배된다.
Q3: 모든 참조를 최신으로 유지하던 API가 그 규칙을 포기하면 위험한 이유는?
A: 기존 클라이언트가 "참조가 항상 유효하다"는 전제로 코드를 작성했기 때문. 갑자기 허상 포인터가 허용되면 기존 클라이언트에서 NullPointerException 등 예상치 못한 에러가 발생한다. API 계약(contract)이 변경된 것이므로 breaking change에 해당.
Q4: 참조 필드와 복사본 필드를 어떻게 구분하는가?
A: 참조 필드:
authorId: string(접미사 Id 사용). 복사본 필드:author: { name: string }(Id 없이 타입명 사용, id 필드 생략). 이 네이밍 규칙으로 클라이언트가 참조인지 복사본인지 바로 구분할 수 있다.
부록 A: 용어 사전
| 용어 | 영문 | 의미 |
|---|---|---|
| 교차 참조 | Cross Reference | 한 리소스가 다른 리소스의 식별자를 저장하여 가리키는 것 |
| 허상 포인터 | Dangling Pointer | 삭제된 리소스를 가리키는 유효하지 않은 참조 |
| 고아 레코드 | Orphaned Record | 참조 대상이 사라진 레코드 |
| 순환 참조 | Circular Reference | 두 리소스가 서로를 참조하는 상태 |
| 동적 리소스 타입 | Dynamic Resource Type | 참조 대상의 타입이 상황에 따라 달라지는 참조 |
| 참조 무결성 | Referential Integrity | 모든 참조가 유효한 대상을 가리키는 상태 |
| 인라인 복사본 | Inline Copy | 참조 대신 데이터를 직접 포함하는 방식 (스냅샷) |
| BFF 패턴 | Backend For Frontend | 프론트엔드 요구에 맞춰 여러 API 호출을 합치는 중간 레이어 |
부록 B: 핵심 비교표
참조 vs 값 비교
| 항목 | 참조 (포인터) | 값 (복사본) |
|---|---|---|
| 저장 | 식별자만 저장 | 전체 데이터 저장 |
| 최신성 | 항상 최신 | 스냅샷 (취득 시점) |
| API 호출 | 추가 호출 필요 | 한 번에 모두 취득 |
| 응답 크기 | 작음 | 큼 |
| 일관성 | 자동 보장 | 수동 관리 필요 |
| 필드 네이밍 | authorId (Id 접미사) | author (Id 없음) |
허상 포인터 대응 비교
| 방식 | 허상 포인터 | 순환 참조 | 대규모 적합 | 부수 효과 | 권장 |
|---|---|---|---|---|---|
| 삭제 금지 | 없음 | ❌ 교착상태 | ❌ | 없음 | ⚠️ |
| 참조 재설정 | 없음 | ✅ | ❌ 느림 | ✅ 있음 | ⚠️ |
| 허상 허용 | 있음 | ✅ | ✅ | 없음 | ✅ 권장 |
부록 C: 추천 참고 자료 & 링크
| 자료 | 설명 |
|---|---|
| 6장 리소스 식별자 | 고유 식별자 체계 |
| 14장 연관 리소스 | 다대다 관계의 명시적 모델링 |
| 15장 add/remove | 다대다 관계의 간소화된 모델링 |
| GraphQL | 복수 참조를 한 번에 해결하는 쿼리 언어 |
클릭하거나 Space를 눌러 뒤집기